]>
Commit | Line | Data |
---|---|---|
1 | /* | |
2 | Copyright (C) 2024 Rubén Beltrán del Río | |
3 | ||
4 | This program is free software: you can redistribute it and/or modify | |
5 | it under the terms of the GNU General Public License as published by | |
6 | the Free Software Foundation, either version 3 of the License, or | |
7 | (at your option) any later version. | |
8 | ||
9 | This program is distributed in the hope that it will be useful, | |
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | GNU General Public License for more details. | |
13 | ||
14 | You should have received a copy of the GNU General Public License | |
15 | along with this program. If not, see https://map.tranquil.systems. | |
16 | */ | |
17 | import Cocoa | |
18 | import SwiftUI | |
19 | ||
20 | class MapTextEditorController: NSViewController { | |
21 | ||
22 | @Binding var document: MapDocument | |
23 | var highlightRanges: [Range<String.Index>] { | |
24 | didSet { | |
25 | updateHighlights() | |
26 | } | |
27 | } | |
28 | ||
29 | var selectedRange: Int { | |
30 | didSet { | |
31 | updateHighlights() | |
32 | focusOnResult() | |
33 | } | |
34 | } | |
35 | ||
36 | let onChange: () -> Void | |
37 | ||
38 | private let vertexRegex = MapParsingPatterns.vertex | |
39 | private let edgeRegex = MapParsingPatterns.edge | |
40 | private let blockerRegex = MapParsingPatterns.blocker | |
41 | private let opportunityRegex = MapParsingPatterns.opportunity | |
42 | private let noteRegex = MapParsingPatterns.note | |
43 | private let stageRegex = MapParsingPatterns.stage | |
44 | private let groupRegex = MapParsingPatterns.group | |
45 | ||
46 | private let changeDebouncer: Debouncer = Debouncer(seconds: 1) | |
47 | ||
48 | init( | |
49 | document: Binding<MapDocument>, highlightRanges: [Range<String.Index>], selectedRange: Int, | |
50 | onChange: @escaping () -> Void | |
51 | ) { | |
52 | self._document = document | |
53 | self.onChange = onChange | |
54 | self.highlightRanges = highlightRanges | |
55 | self.selectedRange = selectedRange | |
56 | super.init(nibName: nil, bundle: nil) | |
57 | } | |
58 | ||
59 | required init?(coder: NSCoder) { | |
60 | fatalError("init(coder:) has not been implemented") | |
61 | } | |
62 | ||
63 | override func loadView() { | |
64 | let scrollView = NSTextView.scrollableTextView() | |
65 | let textView = scrollView.documentView as! NSTextView | |
66 | ||
67 | scrollView.translatesAutoresizingMaskIntoConstraints = false | |
68 | ||
69 | textView.backgroundColor = .ui.background | |
70 | textView.allowsUndo = true | |
71 | textView.delegate = self | |
72 | textView.textStorage?.delegate = self | |
73 | textView.string = self.document.text | |
74 | textView.isEditable = true | |
75 | textView.font = .monospacedSystemFont(ofSize: 16.0, weight: .regular) | |
76 | self.view = scrollView | |
77 | } | |
78 | ||
79 | override func viewDidAppear() { | |
80 | self.view.window?.makeFirstResponder(self.view) | |
81 | updateHighlights() | |
82 | } | |
83 | ||
84 | private var textView: NSTextView? { | |
85 | return (view as? NSScrollView)?.documentView as? NSTextView | |
86 | } | |
87 | ||
88 | private func updateHighlights() { | |
89 | if let textView { | |
90 | if let textStorage = textView.textStorage { | |
91 | textStorage.removeAttribute( | |
92 | .backgroundColor, range: NSRange(location: 0, length: textStorage.length)) | |
93 | ||
94 | for range in highlightRanges { | |
95 | let nsRange = NSRange(range, in: textStorage.string) | |
96 | ||
97 | textStorage.addAttribute(.backgroundColor, value: NSColor.syntax.match, range: nsRange) | |
98 | } | |
99 | ||
100 | textView.needsDisplay = true | |
101 | ||
102 | } | |
103 | } | |
104 | } | |
105 | ||
106 | private func focusOnResult() { | |
107 | if let textView { | |
108 | if let textStorage = textView.textStorage { | |
109 | if selectedRange < highlightRanges.count { | |
110 | let range = highlightRanges[selectedRange] | |
111 | let nsRange = NSRange(range, in: textStorage.string) | |
112 | textView.scrollRangeToVisible(nsRange) | |
113 | textView.selectedRange = nsRange | |
114 | } | |
115 | } | |
116 | } | |
117 | } | |
118 | ||
119 | private func setSelectionColor() { | |
120 | guard let textView = self.textView else { return } | |
121 | ||
122 | var selectedTextAttributes = textView.selectedTextAttributes | |
123 | selectedTextAttributes[.backgroundColor] = NSColor.yellow.withAlphaComponent(0.3) | |
124 | textView.selectedTextAttributes = selectedTextAttributes | |
125 | } | |
126 | } | |
127 | ||
128 | extension MapTextEditorController: NSTextViewDelegate { | |
129 | ||
130 | func textDidChange(_ obj: Notification) { | |
131 | if let textField = obj.object as? NSTextView { | |
132 | self.document.text = textField.string | |
133 | ||
134 | changeDebouncer.debounce { | |
135 | DispatchQueue.main.async { | |
136 | self.onChange() | |
137 | } | |
138 | } | |
139 | } | |
140 | } | |
141 | ||
142 | func textView(_ view: NSTextView, shouldChangeTextIn: NSRange, replacementString: String?) -> Bool | |
143 | { | |
144 | let range = Range(shouldChangeTextIn, in: view.string) | |
145 | let target = view.string[range!] | |
146 | ||
147 | if target == "--" { | |
148 | return false | |
149 | } | |
150 | ||
151 | return true | |
152 | } | |
153 | } | |
154 | ||
155 | extension MapTextEditorController: NSTextStorageDelegate { | |
156 | ||
157 | override func textStorageDidProcessEditing(_ obj: Notification) { | |
158 | if let textStorage = obj.object as? NSTextStorage { | |
159 | self.colorizeText(textStorage: textStorage) | |
160 | } | |
161 | } | |
162 | ||
163 | private func colorizeText(textStorage: NSTextStorage) { | |
164 | let range = NSMakeRange(0, textStorage.length) | |
165 | var matches = vertexRegex.matches(in: textStorage.string, options: [], range: range) | |
166 | ||
167 | for match in matches { | |
168 | textStorage.addAttributes( | |
169 | [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) | |
170 | textStorage.addAttributes( | |
171 | [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) | |
172 | textStorage.addAttributes( | |
173 | [.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) | |
174 | textStorage.addAttributes( | |
175 | [.foregroundColor: NSColor.syntax.option], range: match.range(at: 4)) | |
176 | } | |
177 | ||
178 | matches = edgeRegex.matches(in: textStorage.string, options: [], range: range) | |
179 | ||
180 | for match in matches { | |
181 | textStorage.addAttributes( | |
182 | [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) | |
183 | let arrowRange = match.range(at: 2) | |
184 | textStorage.addAttributes( | |
185 | [.foregroundColor: NSColor.syntax.symbol], | |
186 | range: NSMakeRange(arrowRange.lowerBound - 1, arrowRange.length + 1)) | |
187 | textStorage.addAttributes( | |
188 | [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 3)) | |
189 | } | |
190 | ||
191 | matches = opportunityRegex.matches(in: textStorage.string, options: [], range: range) | |
192 | ||
193 | for match in matches { | |
194 | textStorage.addAttributes( | |
195 | [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) | |
196 | textStorage.addAttributes( | |
197 | [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) | |
198 | textStorage.addAttributes( | |
199 | [.foregroundColor: NSColor.syntax.symbol], range: match.range(at: 3)) | |
200 | textStorage.addAttributes( | |
201 | [.foregroundColor: NSColor.syntax.number], range: match.range(at: 4)) | |
202 | } | |
203 | ||
204 | matches = blockerRegex.matches(in: textStorage.string, options: [], range: range) | |
205 | ||
206 | for match in matches { | |
207 | textStorage.addAttributes( | |
208 | [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) | |
209 | textStorage.addAttributes( | |
210 | [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) | |
211 | } | |
212 | ||
213 | matches = noteRegex.matches(in: textStorage.string, options: [], range: range) | |
214 | ||
215 | for match in matches { | |
216 | textStorage.addAttributes( | |
217 | [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) | |
218 | textStorage.addAttributes( | |
219 | [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) | |
220 | textStorage.addAttributes( | |
221 | [.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) | |
222 | } | |
223 | ||
224 | matches = stageRegex.matches(in: textStorage.string, options: [], range: range) | |
225 | ||
226 | for match in matches { | |
227 | textStorage.addAttributes( | |
228 | [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) | |
229 | textStorage.addAttributes( | |
230 | [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) | |
231 | } | |
232 | ||
233 | matches = groupRegex.matches(in: textStorage.string, options: [], range: range) | |
234 | ||
235 | for match in matches { | |
236 | textStorage.addAttributes( | |
237 | [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) | |
238 | textStorage.addAttributes( | |
239 | [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) | |
240 | } | |
241 | } | |
242 | } | |
243 | ||
244 | struct MapTextEditor: NSViewControllerRepresentable { | |
245 | ||
246 | @Binding var document: MapDocument | |
247 | var highlightRanges: [Range<String.Index>] | |
248 | var selectedRange: Int | |
249 | var onChange: () -> Void = {} | |
250 | ||
251 | func makeNSViewController( | |
252 | context: NSViewControllerRepresentableContext<MapTextEditor> | |
253 | ) -> MapTextEditorController { | |
254 | return MapTextEditorController( | |
255 | document: $document, highlightRanges: highlightRanges, selectedRange: selectedRange, | |
256 | onChange: onChange) | |
257 | } | |
258 | ||
259 | func updateNSViewController( | |
260 | _ nsViewController: MapTextEditorController, | |
261 | context: NSViewControllerRepresentableContext<MapTextEditor> | |
262 | ) { | |
263 | nsViewController.highlightRanges = highlightRanges | |
264 | nsViewController.selectedRange = selectedRange | |
265 | } | |
266 | } |